// Copyright (c) 2017, the Dart project authors.  Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

import "dart:async";
import "dart:io";
import "dart:isolate";
import "dart:math";

import "package:async_helper/async_helper.dart";
import "package:expect/expect.dart";

const String loopbackIPv4String = "127.0.0.1";

void testArguments() {
  Expect.throws(() => RawSynchronousSocket.connectSync(null, 0));
  Expect.throws(
      () => RawSynchronousSocket.connectSync(loopbackIPv4String, 65536));
  Expect.throws(() => RawSynchronousSocket.connectSync(loopbackIPv4String, -1));
}

// The connection attempt happens on the main Dart thread and the OS timeout can
// be arbitrarily long, causing timeout issues on the build bots. This isn't an
// issue with the async sockets since the lookup for a connect call happens on
// the IO service thread.
/*
void testInvalidConnect() {
  // Connect to an unknown DNS name.
  try {
    var socket = RawSynchronousSocket.connectSync("ko.faar.__hest__", 0);
    Expect.fail("Failure expected");
  } catch (e) {
    Expect.isTrue(e is SocketException);
  }

  // Connect to an unavailable IP-address.
  try {
    var socket = RawSynchronousSocket.connectSync("1.2.3.4", 0);
    Expect.fail("Failure expected");
  } catch (e) {
    Expect.isTrue(e is SocketException);
  }
}
*/

void testSimpleConnect() {
  asyncStart();
  RawServerSocket.bind(InternetAddress.loopbackIPv4, 0).then((server) {
    var socket =
        RawSynchronousSocket.connectSync(loopbackIPv4String, server.port);
    server.listen((serverSocket) {
      Expect.equals(socket.address, serverSocket.remoteAddress);
      Expect.equals(socket.port, serverSocket.remotePort);
      Expect.equals(socket.remoteAddress, server.address);
      Expect.equals(socket.remotePort, server.port);
      socket.closeSync();
      server.close();
      asyncEnd();
    });
  });
}

void testServerListenAfterConnect() {
  asyncStart();
  RawServerSocket.bind(InternetAddress.loopbackIPv4, 0).then((server) {
    Expect.isTrue(server.port > 0);
    var client =
        RawSynchronousSocket.connectSync(loopbackIPv4String, server.port);
    server.listen((socket) {
      client.closeSync();
      server.close();
      socket.close();
      asyncEnd();
    });
  });
}

const messageSize = 1000;
// Configuration fields for the EchoServer.
enum EchoServerTypes {
  // Max accumulated connections to server before close. Defaults to 1.
  CONNECTION_COUNT,
  // Sets the range of the fields to check in the list generated by
  // createTestData().
  OFFSET_END,
  OFFSET_START,
  // The port used to communicate with an isolate.
  ISOLATE_SEND_PORT,
  // The port of the newly created echo server.
  SERVER_PORT
}

List<int> createTestData() {
  return new List<int>.generate(messageSize, (index) => index & 0xff);
}

// Consumes data generated by a test and compares it against the original test
// data. The optional fields, start and end, are used to compare against
// segments of the original test data list. In other words, data.length == (end
// - start).
void verifyTestData(List<int> data, [int start = 0, int? end]) {
  assert(data != null);
  List<int> expected = createTestData();
  if (end == null) {
    end = data.length;
  }
  end = min(messageSize, end);
  Expect.equals(end - start, data.length);
  for (int i = 0; i < (end - start); i++) {
    Expect.equals(expected[start + i], data[i]);
  }
}

// The echo server is spawned in a new isolate and is used to test various
// synchronous read/write operations by echoing any data received back to the
// sender. The server should shutdown automatically after a specified number of
// socket disconnections (default: 1).
Future echoServer(var sendPort) async {
  RawServerSocket.bind(InternetAddress.loopbackIPv4, 0).then((server) async {
    ReceivePort receivePort = new ReceivePort();
    Map response = {
      EchoServerTypes.ISOLATE_SEND_PORT: receivePort.sendPort,
      EchoServerTypes.SERVER_PORT: server.port
    };
    sendPort.send(response);
    Map limits = await receivePort.first;
    int start = limits[EchoServerTypes.OFFSET_START];
    int end = limits[EchoServerTypes.OFFSET_END];
    int length = end - start;
    int connection_count = limits[EchoServerTypes.CONNECTION_COUNT] ?? 1;
    int connections = 0;
    sendPort = limits[EchoServerTypes.ISOLATE_SEND_PORT];
    server.listen((client) {
      int bytesRead = 0;
      int bytesWritten = 0;
      bool closedEventReceived = false;
      List<int> data = new List<int>.filled(length, 0);
      client.writeEventsEnabled = false;
      client.listen((event) {
        switch (event) {
          case RawSocketEvent.read:
            Expect.isTrue(bytesWritten == 0);
            Expect.isTrue(client.available() > 0);
            var buffer = client.read(client.available())!;
            data.setRange(bytesRead, bytesRead + buffer.length, buffer);
            bytesRead += buffer.length;
            // Once we've read all the data, we can echo it back. Otherwise,
            // keep waiting for more bytes.
            if (bytesRead >= length) {
              verifyTestData(data, start, end);
              client.writeEventsEnabled = true;
            }
            break;
          case RawSocketEvent.write:
            Expect.isFalse(client.writeEventsEnabled);
            bytesWritten +=
                client.write(data, bytesWritten, data.length - bytesWritten);
            if (bytesWritten < length) {
              client.writeEventsEnabled = true;
            } else if (bytesWritten == length) {
              // Close the socket for writing from the server since we're done
              // writing to this socket. The connection is closed completely
              // after the client closes the socket for reading from the server.
              client.shutdown(SocketDirection.send);
            }
            break;
          case RawSocketEvent.readClosed:
            client.close();
            break;
          case RawSocketEvent.closed:
            Expect.isFalse(closedEventReceived);
            closedEventReceived = true;
            break;
          default:
            throw "Unexpected event $event";
        }
      }, onDone: () {
        Expect.isTrue(closedEventReceived);
        connections++;
        if (connections >= connection_count) {
          server.close();
        }
      });
    }, onDone: () {
      // Let the client know we're shutting down then kill the isolate.
      sendPort.send(null);
      Isolate.current.kill();
    });
  });
}

Future testSimpleReadWrite({bool? dropReads}) async {
  asyncStart();
  // This test creates a server and a client connects. The client writes data
  // to the socket and the server echos it back. The client confirms the data it
  // reads is the same as the data sent, then closes the socket, resulting in
  // the closing of the server, which responds on receivePort with null to
  // specify the echo server isolate is about to be killed. If an error occurs
  // in the echo server, the exception and stack trace are sent to receivePort,
  // which prints the exception and stack trace before eventually throwing an
  // error.
  ReceivePort receivePort = new ReceivePort();
  Isolate echo = await Isolate.spawn(echoServer, receivePort.sendPort);

  Map response = await receivePort.first;
  SendPort sendPort = response[EchoServerTypes.ISOLATE_SEND_PORT];
  int serverInternetPort = response[EchoServerTypes.SERVER_PORT];

  receivePort = new ReceivePort();
  echo.addErrorListener(receivePort.sendPort);

  Map limits = {
    EchoServerTypes.OFFSET_START: 0,
    EchoServerTypes.OFFSET_END: messageSize,
    EchoServerTypes.ISOLATE_SEND_PORT: receivePort.sendPort
  };
  sendPort.send(limits);

  try {
    var socket = RawSynchronousSocket.connectSync(
        loopbackIPv4String, serverInternetPort);
    List<int> data = createTestData();
    socket.writeFromSync(data);
    List<int> result = socket.readSync(data.length)!;
    verifyTestData(result);
    socket.shutdown(SocketDirection.send);
    socket.closeSync();
  } catch (e, stack) {
    print("Echo test failed in the client");
    rethrow;
  }
  // Wait for the server to shutdown before finishing the test.
  var result = await receivePort.first;
  if (result != null) {
    throw "Echo test failed in server!\nError: ${result[0]}\nStack trace:" +
        " ${result[1]}";
  }
  asyncEnd();
}

Future testPartialRead() async {
  asyncStart();
  // This test is based on testSimpleReadWrite, but instead of reading the
  // entire echoed message at once, it reads it in two calls to readIntoSync.
  ReceivePort receivePort = new ReceivePort();
  Isolate echo = await Isolate.spawn(echoServer, receivePort.sendPort);

  Map response = await receivePort.first;
  SendPort sendPort = response[EchoServerTypes.ISOLATE_SEND_PORT];
  int serverInternetPort = response[EchoServerTypes.SERVER_PORT];
  List<int> data = createTestData();

  receivePort = new ReceivePort();
  echo.addErrorListener(receivePort.sendPort);

  Map limits = {
    EchoServerTypes.OFFSET_START: 0,
    EchoServerTypes.OFFSET_END: 1000,
    EchoServerTypes.ISOLATE_SEND_PORT: receivePort.sendPort
  };
  sendPort.send(limits);

  try {
    var socket = RawSynchronousSocket.connectSync(
        loopbackIPv4String, serverInternetPort);
    int half_length = (data.length / 2).toInt();

    // Send the full data list to the server.
    socket.writeFromSync(data);
    List<int> result = new List<int>.filled(data.length, 0);

    // Read half at a time and check that there's still more bytes available.
    socket.readIntoSync(result, 0, half_length);
    verifyTestData(result.sublist(0, half_length), 0, half_length);
    Expect.isTrue(socket.available() == (data.length - half_length));

    // Read the second half and verify again.
    socket.readIntoSync(result, half_length);
    verifyTestData(result);
    Expect.isTrue(socket.available() == 0);

    socket.closeSync();
  } catch (e, stack) {
    print("Echo test failed in the client.");
    rethrow;
  }
  // Wait for the server to shutdown before finishing the test.
  var result = await receivePort.first;
  if (result != null) {
    throw "Echo test failed in server!\nError: ${result[0]}\nStack trace:" +
        " ${result[1]}";
  }
  asyncEnd();
}

Future testPartialWrite() async {
  asyncStart();
  // This test is based on testSimpleReadWrite, but instead of writing the
  // entire data buffer at once, it writes different parts of the buffer over
  // multiple calls to writeFromSync.
  ReceivePort receivePort = new ReceivePort();
  Isolate echo = await Isolate.spawn(echoServer, receivePort.sendPort);

  Map response = await receivePort.first;
  List<int> data = createTestData();
  SendPort sendPort = response[EchoServerTypes.ISOLATE_SEND_PORT];
  int startOffset = 32;
  int endOffset = (data.length / 2).toInt();
  int serverInternetPort = response[EchoServerTypes.SERVER_PORT];

  receivePort = new ReceivePort();
  echo.addErrorListener(receivePort.sendPort);

  Map limits = {
    EchoServerTypes.OFFSET_START: startOffset,
    EchoServerTypes.OFFSET_END: endOffset,
    EchoServerTypes.ISOLATE_SEND_PORT: receivePort.sendPort
  };
  sendPort.send(limits);
  try {
    var socket = RawSynchronousSocket.connectSync(
        loopbackIPv4String, serverInternetPort);
    List<int> data = createTestData();

    // Write a subset of data to the server.
    socket.writeFromSync(data, startOffset, endOffset);

    // Grab the response and verify it's correct.
    List<int> result = new List<int>.filled(endOffset - startOffset, 0);
    socket.readIntoSync(result);

    Expect.equals(result.length, endOffset - startOffset);
    verifyTestData(result, startOffset, endOffset);
    socket.closeSync();
  } catch (e, stack) {
    print("Echo test failed in the client.");
    rethrow;
  }

  // Wait for the server to shutdown before finishing the test.
  var result = await receivePort.first;
  if (result != null) {
    throw "Echo test failed in server!\nError: ${result[0]}\nStack trace:" +
        " ${result[1]}";
  }
  asyncEnd();
}

Future testShutdown() async {
  asyncStart();
  // This test creates a server and a client connects. The client then tries to
  // perform various operations after being shutdown in a specific direction, to
  // ensure reads or writes cannot be performed if the socket has been shutdown
  // for reading or writing.
  ReceivePort receivePort = new ReceivePort();
  Isolate echo = await Isolate.spawn(echoServer, receivePort.sendPort);

  Map response = await receivePort.first;
  SendPort sendPort = response[EchoServerTypes.ISOLATE_SEND_PORT];
  int serverInternetPort = response[EchoServerTypes.SERVER_PORT];
  List<int> data = createTestData();

  receivePort = new ReceivePort();
  echo.addErrorListener(receivePort.sendPort);

  Map limits = {
    EchoServerTypes.OFFSET_START: 0,
    EchoServerTypes.OFFSET_END: data.length,
    EchoServerTypes.ISOLATE_SEND_PORT: receivePort.sendPort,
    // Tell the server to shutdown after 3 sockets disconnect.
    EchoServerTypes.CONNECTION_COUNT: 3
  };
  sendPort.send(limits);

  try {
    var socket = RawSynchronousSocket.connectSync(
        loopbackIPv4String, serverInternetPort);

    // Close from both directions. Shouldn't be able to read/write to the
    // socket.
    socket.shutdown(SocketDirection.both);
    Expect.throws(
        () => socket.writeFromSync(data), (e) => e is SocketException);
    Expect.throws(
        () => socket.readSync(data.length), (e) => e is SocketException);
    socket.closeSync();

    // Close the socket for reading then try and perform a read. This should
    // cause a SocketException.
    socket = RawSynchronousSocket.connectSync(
        loopbackIPv4String, serverInternetPort);
    socket.shutdown(SocketDirection.receive);
    // Throws exception when the socket is closed for RECEIVE.
    Expect.throws(
        () => socket.readSync(data.length), (e) => e is SocketException);
    socket.closeSync();

    // Close the socket for writing and try to do a write. This should cause an
    // OSError to be throw as the pipe is closed for writing.
    socket = RawSynchronousSocket.connectSync(
        loopbackIPv4String, serverInternetPort);
    socket.shutdown(SocketDirection.send);
    Expect.throws(
        () => socket.writeFromSync(data), (e) => e is SocketException);
    socket.closeSync();
  } catch (e, stack) {
    print("Echo test failed in client.");
    rethrow;
  }
  // Wait for the server to shutdown before finishing the test.
  var result = await receivePort.first;
  if (result != null) {
    throw "Echo test failed in server!\nError: ${result[0]}\nStack trace:" +
        " ${result[1]}";
  }
  asyncEnd();
}

void testInvalidReadWriteOperations() {
  asyncStart();
  RawServerSocket.bind(InternetAddress.loopbackIPv4, 0).then((server) {
    server.listen((socket) {});
    List<int> data = createTestData();
    var socket =
        RawSynchronousSocket.connectSync(loopbackIPv4String, server.port);

    // Invalid writeFromSync invocations
    Expect.throwsRangeError(() => socket.writeFromSync(data, data.length + 1));
    Expect.throwsRangeError(
        () => socket.writeFromSync(data, 0, data.length + 1));
    Expect.throwsRangeError(() => socket.writeFromSync(data, 1, 0));

    // Invalid readIntoSync invocations
    List<int> buffer = new List<int>.filled(10, 0);
    Expect.throwsRangeError(
        () => socket.readIntoSync(buffer, buffer.length + 1));
    Expect.throwsRangeError(
        () => socket.readIntoSync(buffer, 0, buffer.length + 1));
    Expect.throwsRangeError(() => socket.readIntoSync(buffer, 1, 0));

    // Invalid readSync invocation
    Expect.throwsArgumentError(() => socket.readSync(-1));

    server.close();
    socket.closeSync();
    asyncEnd();
  });
}

void testClosedError() {
  asyncStart();
  RawServerSocket.bind(InternetAddress.loopbackIPv4, 0).then((server) {
    server.listen((socket) {
      socket.close();
    });
    var socket =
        RawSynchronousSocket.connectSync(loopbackIPv4String, server.port);
    server.close();
    socket.closeSync();
    Expect.throws(() => socket.remotePort, (e) => e is SocketException);
    Expect.throws(() => socket.remoteAddress, (e) => e is SocketException);
    asyncEnd();
  });
}

main() async {
  asyncStart();
  testArguments();
  // testInvalidConnect(); Long timeout for bad lookups, so disable for bots.
  await testShutdown();
  testSimpleConnect();
  testServerListenAfterConnect();
  await testSimpleReadWrite();
  await testPartialRead();
  await testPartialWrite();
  testInvalidReadWriteOperations();
  testClosedError();
  asyncEnd();
}
